2 A Tidyverse Primer

Published

September 10, 2025

Modified

September 10, 2025

什么是tidyverse,tidymodels框架又在其中扮演什么角色呢?tidyverse是一系列R数据分析包的集合,这些包是基于共同的理念和规范开发的。用Wickham等人的话讲:

“At a high level, the tidyverse is a language for solving data science challenges with R code. Its primary goal is to facilitate a conversation between a human and a computer about data. Less abstractly, the tidyverse is a collection of R packages that share a high-level design philosophy and low-level grammar and data structures, so that learning one package makes it easier to learn the next.”

在本章中,我们简要讨论tidyverse设计理念的重要原则,以及这些原则如何应用于易于正确使用且支持良好统计实践的建模软件中。下一章将介绍base R中的建模惯例。通过这些讨论,你可以理解tidyverse、tidymodels与base R之间的关系:tidymodels和tidyverse都建立在base R之上,而tidymodels则将tidyverse的原则应用于模型构建。

Tidyverse Principles

有关以tidyverse风格编写R代码的全套策略和技巧,可以在网站https://design.tidyverse.org上找到。在这里,我们可以简要介绍tidyverse的几个通用设计原则、其背后的动机,以及我们如何将建模视为这些原则的一种应用。

library(tidyverse)
#> ── Attaching core tidyverse packages ───────────────────── tidyverse 2.0.0 ──
#> ✔ dplyr     1.1.4     ✔ readr     2.1.5
#> ✔ forcats   1.0.0     ✔ stringr   1.5.1
#> ✔ ggplot2   3.5.2     ✔ tibble    3.2.1
#> ✔ lubridate 1.9.4     ✔ tidyr     1.3.1
#> ✔ purrr     1.1.0     
#> ── Conflicts ─────────────────────────────────────── tidyverse_conflicts() ──
#> ✖ dplyr::filter() masks stats::filter()
#> ✖ dplyr::lag()    masks stats::lag()
#> ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors

Design for humans

tidyverse 致力于设计易于广大人群理解和使用的R包及函数。无论是过去还是现在,很大一部分R用户并非是创建软件或工具的人,而是进行分析或建模的人。因此,R用户通常没有(也不需要)计算机科学背景,而且许多人对编写自己的R包并不感兴趣。

出于这个原因,R代码必须易于操作以实现你的目标,这一点至关重要。文档、培训、可及性以及其他因素在实现这一目标中发挥着重要作用。然而,如果语法本身难以被人们轻松理解,那么文档就不是一个好的解决方案。软件本身必须具有直观性。

为了将tidyverse方法与更传统的R语义进行对比,我们来考虑对数据框进行排序。数据框的每一列可以表示不同类型的数据,每一行可以包含多个值。仅使用核心语言时,我们可以结合R的抽取规则和order()函数,通过重新排列行的顺序,依据一个或多个列对数据框进行排序;在这种情况下,你可能会因为一个函数的名称而想用它,但实际上用sort()是无法成功的。要依据mtcars数据中的两列对其进行排序,调用方式可能如下:

mtcars[order(mtcars$gear, mtcars$mpg), ]

虽然计算效率很高,但很难说这是一个直观的用户界面。相比之下,在dplyr中,tidyverse函数arrange()直接将一组变量名作为输入参数:

library(dplyr)
arrange(.data = mtcars, gear, mpg)

这里使用的变量名是“未加引号的”;许多传统的R函数需要用字符串来指定变量,但tidyverse函数接受未加引号的名称或选择器函数。这些选择器允许应用一个或多个易懂的规则到列名上。例如,ends_with("t")会选择mtcars数据框中的drat列和wt列。

此外,命名至关重要。如果你是R语言新手,正在编写涉及线性代数的数据分析或建模代码,那么在寻找计算矩阵逆的函数时可能会遇到困难。使用apropos("inv")不会得到任何候选结果。事实证明,base R中用于此任务的函数是solve(),它用于求解线性方程组。对于矩阵X,你可以使用solve(X)来求X的逆(此时方程右侧没有向量)。这一点仅在帮助文件中某个参数的描述中有所说明。本质上,你需要知道解决方案的名称才能找到该解决方案。

tidyverse的做法是使用描述性强且明确的函数名,而非简短且隐晦的函数名。通用方法侧重于动词(例如,fitarrange等)。动词-名词组合尤其有效,不妨考虑将invert_matrix()作为一个假设的函数名。在建模的语境下,避免使用高度专业的术语也很重要,比如希腊字母或生僻词汇。函数名应尽可能做到自说明。

当一个包中存在类似的函数时,函数名的设计会针对制表符补全进行优化。例如,glue包中有一系列函数都以一个共同的前缀(glue_)开头,这能让用户快速找到他们想要的函数。

Reuse existing data structures

只要有可能,函数应避免返回全新的数据结构。如果结果适合现有的数据结构,就应使用该数据结构。这会减轻使用软件时的认知负担,无需额外的语法或方法。

数据框是tidyverse和tidymodels包中首选的数据结构,因为其结构非常适合大量的数据科学任务。具体来说,tidyverse和tidymodels更倾向于使用tibble,这是对R语言数据框的现代重塑,我们将在下一节关于tidyverse语法示例的内容中对其进行介绍。

例如,rsample包可用于创建数据集的重采样,如交叉验证或自助法(在第10章中描述)。重采样函数返回一个tibble,其中包含一个名为splits的列,该列中的对象定义了重采样后的数据集。一个数据集的三个自助样本可能如下所示:

boot_samp <- rsample::bootstraps(mtcars, times = 3)
boot_samp
#> # Bootstrap sampling 
#> # A tibble: 3 × 2
#>   splits          id        
#>   <list>          <chr>     
#> 1 <split [32/13]> Bootstrap1
#> 2 <split [32/11]> Bootstrap2
#> 3 <split [32/13]> Bootstrap3
class(boot_samp)
#> [1] "bootstraps" "rset"       "tbl_df"     "tbl"        "data.frame"

通过这种方法,基于向量的函数可以用于这些列,例如vapply()purrr::map()。这个boot_samp对象有多个类,但继承了数据框("data.frame")和 tibble("tbl_df")的方法。此外,可以向结果中添加新列,而不会影响数据的类。与完全新的、其数据结构不明确的对象类型相比,这对用户来说更容易使用,也更具通用性。

依赖常见数据结构的一个缺点是可能会损失计算性能。在某些情况下,数据可以用专门的格式进行编码,这些格式能更高效地表示数据。例如:

  • 在计算化学中,结构数据文件格式(SDF)是一种工具,它能获取化学结构并将其编码为便于计算处理的格式。

  • 具有大量相同值(例如二进制数据中的零)的数据可以存储在稀疏矩阵格式中。这种格式不仅可以减小数据的大小,还能启用更高效的计算技术。

当问题范围明确,且潜在的数据处理方法既定义清晰又适合这种格式时,这些格式就具有优势。然而,一旦这些约束条件被违反,专用数据格式的用处就会减小。例如,如果我们对数据进行转换,将其转换为分数,那么输出就不再是稀疏的;稀疏矩阵表示有助于建模中的某个特定算法步骤,但在该特定步骤之前或之后,情况往往并非如此。专用数据结构不像通用数据结构那样,足以灵活应对整个建模工作流程。

rsample生成的tibble中有一个重要特征,即splits列是一个列表。在这种情况下,该列表的每个元素都属于同一类型的对象——rsplit对象,其中包含关于mtcars的哪些行属于自助抽样样本的信息。列表列在数据分析中非常有用,并且正如本书通篇将要介绍的那样,它们对tidymodels而言也很重要。

Design for the pipe

magrittr包中的管道符%>%(R 4.0版本后引入|>)是一种将一系列R函数链接在一起的工具。为了说明这一点,考虑以下命令,对数据框进行排序,然后保留前10行:

small_mtcars <- arrange(mtcars, gear)
small_mtcars <- slice(small_mtcars, 1:10)

# or more compactly:
small_mtcars <- slice(arrange(mtcars, gear), 1:10)

管道符可以将左侧函数的运行结果替换为右侧函数的第一个参数,因此我们可以通过以下方式实现与之前相同的结果:

small_mtcars <-
  mtcars %>%
  arrange(gear) %>%
  slice(1:10)

这一系列函数的管道符版本更具可读性;随着更多操作被添加,这种可读性会进一步提高。这一系列函数可以被管道符串联起来,是因为所有函数都返回相同的数据结构(一个数据框),而这个数据结构随后会成为下一个函数的第一个参数。这是有意设计的,在可能的情况下,应创建能够整合到管道符中的函数。

管道符在建模工作流中非常有用,不过建模时管道符间传递的可以不是数据框,而是诸如模型组件之类的对象。

Design for functional programming

R语言拥有出色的工具来创建、修改函数以及对函数进行操作,这使其成为一门非常适合函数式编程的语言。这种方法在很多情况下可以替代迭代循环,例如当函数返回一个值且没有其他副作用时。

让我们来看一个例子。假设你对燃油效率与汽车重量之比的对数感兴趣。对于那些刚接触R语言或者从其他编程语言转过来的人来说,循环可能看起来是个不错的选择:

n <- nrow(mtcars)
ratios <- rep(NA_real_, n)
for (car in 1:n) {
  ratios[car] <- log(mtcars$mpg[car] / mtcars$wt[car])
}
head(ratios)
#> [1] 2.081348 1.988470 2.285193 1.895564 1.693052 1.654643

那些在R语言方面有更多经验的人可能知道,有一种更简单、更快的向量化版本可以通过以下方式计算:

ratios <- log(mtcars$mpg / mtcars$wt)

然而,在许多现实世界的案例中,我们所关注的逐元素运算对于矢量化解决方案来说过于复杂。在这种情况下,一个好的方法是编写一个函数来进行计算。当我们进行函数式编程设计时,重要的是输出应仅取决于输入,且函数没有副作用。以下函数中违背这些原则的地方已用注释标出:

compute_log_ratio <- function(mpg, wt) {
  log_base <- getOption("log_base", default = exp(1)) # gets external data
  results <- log(mpg/wt, base = log_base)
  print(mean(results))                                # prints to the console
  done <<- TRUE                                       # sets external data
  results
}

一个更好的版本是:

compute_log_ratio <- function(mpg, wt, log_base = exp(1)) {
  log(mpg / wt, base = log_base)
}

purrr包包含用于函数式编程的工具。让我们重点关注map()函数族,它们对向量进行操作,并且总是返回相同类型的输出。最基本的函数map()总是返回一个列表,其基本语法为map(vector, function)。例如,要对我们的数据求平方根,我们可以:

map(head(mtcars$mpg, 3), sqrt)
#> [[1]]
#> [1] 4.582576
#> 
#> [[2]]
#> [1] 4.582576
#> 
#> [[3]]
#> [1] 4.774935

存在map()的专门变体,当我们知道或预期函数将生成基本向量类型之一时,这些变体就会返回值。例如,由于平方根会返回双精度数:

map_dbl(head(mtcars$mpg, 3), sqrt)
#> [1] 4.582576 4.582576 4.774935

还有一些映射函数可以跨多个向量进行操作:

log_ratios <- map2_dbl(mtcars$mpg, mtcars$wt, compute_log_ratio)
head(log_ratios)
#> [1] 2.081348 1.988470 2.285193 1.895564 1.693052 1.654643

map()函数还允许使用波浪号定义临时的匿名函数。对于map2(),参数值为.x.y

map2_dbl(mtcars$mpg, mtcars$wt, ~ log(.x / .y)) %>%
  head()
#> [1] 2.081348 1.988470 2.285193 1.895564 1.693052 1.654643

这些示例虽然简单,但在后面的章节中,它们将被应用于更复杂的问题。在整洁建模的函数式编程中,定义的函数应确保能被像map()这样的函数使用,便于迭代计算。

Examples of Tidyverse Syntax

让我们通过更深入地探究什么是tibble以及tibble的工作原理,来开始对tidyverse语法的讨论。在R语言中,tibble与基本数据框(data.frame)的规则略有不同。例如,tibble自然支持那些不符合语法规则的变量名作为列名:

# Wants valid names:
data.frame(`variable 1` = 1:2, two = 3:4)
#>   variable.1 two
#> 1          1   3
#> 2          2   4
# But can be coerced to use them with an extra option:
df <- data.frame(`variable 1` = 1:2, two = 3:4, check.names = FALSE)
df
#>   variable 1 two
#> 1          1   3
#> 2          2   4

# But tibbles just work:
tbbl <- tibble(`variable 1` = 1:2, two = 3:4)
tbbl
#> # A tibble: 2 × 2
#>   `variable 1`   two
#>          <int> <int>
#> 1            1     3
#> 2            2     4

标准数据框支持参数的部分匹配,因此仅使用列名的一部分的代码仍然可以运行。而tibble会阻止这种情况发生,因为这可能会导致意外错误。

df$tw
#> [1] 3 4

tbbl$tw
#> Warning: Unknown or uninitialised column: `tw`.
#> NULL

Tibbles还能避免一种最常见的R语言错误:维度丢失。如果标准数据框将列子集化到只剩一列,该对象会被转换为向量。而Tibbles绝不会这样做:

df[, "two"]
#> [1] 3 4

tbbl[, "two"]
#> # A tibble: 2 × 1
#>     two
#>   <int>
#> 1     3
#> 2     4

使用tibble而非数据框还有其他优势,比如更好的打印效果等等。

为了演示一些语法,让我们使用tidyverse函数读取可用于建模的数据。该数据集来自芝加哥市的数据门户,包含该市高架火车站的每日客流量数据。该数据集包含以下列:

  • the station identifier (numeric)
  • the station name (character)
  • the date (character in mm/dd/yyyy format)
  • the day of the week (character)
  • the number of riders (numeric)

我们的tidyverse流程将按顺序执行以下任务:

  • 使用tidyverse包中的readr从源网站读取数据并将其转换为tibble格式。要实现这一点,read_csv()函数可以通过读取初始的若干行来确定数据类型。或者,如果列名和类型已知,可以在R中创建列规范并将其传递给read_csv()

  • 筛选数据以删除一些不需要的列(例如station ID),并将列stationname更改为station。此操作使用select()函数。筛选时,可以使用列名或dplyr选择器函数。选择名称时,可以使用new_name = old_name的参数格式来声明新的变量名。

  • 使用lubridate包中的mdy()函数将日期字段转换为R日期格式。我们还将客流量数据转换为以千为单位。这两项计算都是通过dplyr::mutate()函数执行的。

  • 对每个车站和日期的组合,采用最大的骑行次数。这能缓解少数日期在特定车站出现多条骑行次数记录的问题。我们按车站和日期对骑行数据进行分组,然后在1999个独特组合的每个组合内,用最大值统计量进行汇总。

这些步骤的tidyverse代码如下:

library(tidyverse)
library(lubridate)

url <- "https://data.cityofchicago.org/api/views/5neh-572f/rows.csv?accessType=DOWNLOAD&bom=true&format=true"

all_stations <-
  # Step 1: Read in the data.
  read_csv(url) %>%
  # Step 2: filter columns and rename stationname
  dplyr::select(station = stationname, date, rides) %>%
  # Step 3: Convert the character date field to a date encoding.
  # Also, put the data in units of 1K rides
  mutate(date = mdy(date), rides = rides / 1000) %>%
  # Step 4: Summarize the multiple records using the maximum.
  group_by(date, station) %>%
  summarize(rides = max(rides), .groups = "drop")

这一系列操作流程说明了tidyverse广受欢迎的原因。它采用了一系列数据处理方法,每个转换都有简单易懂的函数;这一系列操作以一种简洁、易读的方式组合在一起。其核心在于用户与软件的交互方式。这种方法让更多人能够学习R语言并实现他们的分析目标,而将这些相同的原则应用于R语言中的建模也能带来同样的好处。

Chapter Summary

本章介绍了tidyverse,重点讲解了其在建模方面的应用,以及tidyverse的设计原则如何为tidymodels框架提供指导。可以将tidymodels框架看作是将tidyverse原则应用于模型构建领域。我们阐述了tidyverse与base R在约定上的差异,并介绍了tidyverse系统的两个重要组成部分:tibbles和管道运算符%>%。数据清理和处理有时可能会让人觉得枯燥,但这些任务在现实世界的建模中至关重要;我们通过一个数据导入和处理的示例,说明了如何使用tibbles、管道以及tidyverse函数。

Back to top